iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Modern Web

用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)系列 第 25

多頁網站想要 SPA 般的轉場,不寫一堆 JS 做得到嗎?

  • 分享至 

  • xImage
  •  

可以。在共用 layout 的 <head> 放一個 <ClientRouter />,Astro 就會接手站內連結,讓多頁網站用 client-side
navigation 換頁,再用 View
Transitions 補上視覺連續感。這一行同時改變了 navigation 模型:document 不再每次完整重載,頁面 script 的執行時機也跟著改變。

範例把最小接法放進現有內容站,實測範圍包含 navigation lifecycle、原生 script、Vue island、route announcer 與 reduced
motion。版本基準是 Astro 7.1.1,查證與實測日期為 2026-07-24。

開啟前,網站原本怎麼換頁?

Day 17 建好的網站骨架BaseLayout.astro
包住 Header、main 與 Footer。原本點擊普通站內連結時,瀏覽器會載入下一份完整 HTML:

點擊 <a>
  → 載入新文件
  → Header / main / Footer 全部重建
  → 頁面 script 執行
  → island hydrate

這是標準多頁網站(MPA)的 navigation,流程簡單、可靠,不需要 router JavaScript。加入 View
Transitions 後,文章仍在 build 時預渲染;瀏覽器收到連結點擊後,則由 Astro 的 client
router 取得下一頁、交換 DOM 與更新 history。

另一種常見寫法是用 JavaScript 自己換頁。一個上線中的多語系品牌官網沒有使用
astro:transitions,它的語言切換是這樣實作的:

// 把 pathname 第一段換成新語系,然後整頁重載
url.pathname = parts.join("/");
window.location.href = url.toString();

語言切換也是換頁,例如 /en/blog 換到 /cn/blogwindow.location.href 賦值仍會走完整重載流程,觸發者從 <a>
變成 JavaScript。它也繞過了瀏覽器對連結的處理:沒有可以 hover 預覽的目標網址,中鍵開新分頁與右鍵複製連結都失效,爬蟲也看不到那條語系連結。

同一個專案的 layout 裡還有一行手動設定:

history.scrollRestoration = "manual";

history.scrollRestoration = 'manual'
會關閉瀏覽器的捲動位置還原,網站必須自行處理。換頁時的捲動處理也因此被拆到另一處設定。

語言切換先保留普通的 <a href>,讓瀏覽器照常處理,SEO 也能取得那條連結。加入 <ClientRouter />
後,站內連結的換頁才會由 Astro 的 client router 接手,捲動位置與 history 也由它管理,不必另設 scrollRestoration<a>
是 ClientRouter 接手導覽的前提;若改用 window.location.href,這次換頁不會經過 ClientRouter。

最小接法放在共用 layout

Astro 7 從 astro:transitions 匯入 ClientRouter,放進所有目標頁共用的 <head>

---
import { ClientRouter } from 'astro:transitions';
---

<html lang="zh-Hant">
  <head>
    <!-- title、meta、JSON-LD -->
    <ClientRouter />
  </head>
  <body>
    <slot />
  </body>
</html>

完整 document 已透過 Day 4 的 layout 與 slot集中到 BaseLayout.astro,所以
<ClientRouter /> 只要在這個檔案加入一次,不必在每個頁面重複放置。

如果舊教學寫的是 <ViewTransitions />,不要直接照抄。Astro 5 已把它改名為
<ClientRouter />,因為這個元件會啟用 client-side routing,功能超過單純呼叫瀏覽器的動畫 API。

共用 <main> 再指定一組清楚、可觀察的轉場:

<main transition:name="page-content" transition:animate="fade">
  <slot />
</main>

transition:name 明確把前後頁面的 main 配成同一組;transition:animate="fade"
使用 Astro 內建的淡入淡出。即使兩個 directive 都不寫,Astro 仍會根據元素類型與 DOM 位置自動配對,產生預設轉場。

三個 transition directive 各管什麼?

Directive 用途 範例設定
transition:name 明確配對舊頁與新頁的元素 main 使用 page-content
transition:animate 覆寫該組元素的預設動畫 main 使用內建 fade
transition:persist 把同一個 DOM 或 island 帶到下一頁 不使用

transition:persist 會保留元件實例與 client
state,適合換頁時不能中斷的音樂播放器。搜尋條件、文章反應按鈕或一般頁面內容未必需要跨頁保留,因此範例不使用
transition:persistDay 8 的 Vue island會隨 DOM
swap 正常卸載,再在新頁 hydrate;轉場與狀態保留分開處理,結果比較容易判讀。

script lifecycle 會跟著改變

Day 7 的原生 script demo原本在 module 頂層抓 DOM、綁 click listener:

const button = document.getElementById("counter");

button?.addEventListener("click", () => {
  // 更新計數
});

完整頁面 navigation 時,每份新 document 都會重新執行自己的 script。啟用 ClientRouter 後,已經執行過的 bundled module
script 不會因為同一個 <script>
再次出現在新頁就自動重跑。離開 demo 再返回時,畫面換成新的按鈕,舊 listener 仍綁在已被移除的按鈕上。

需要對新 DOM 初始化的程式,應改掛在 astro:page-load

function setupNativeDemo() {
  const button = document.getElementById("counter");
  const count = document.getElementById("count");

  if (!button || button.dataset.initialized === "true") return;
  button.dataset.initialized = "true";

  let clicks = 0;
  button.addEventListener("click", () => {
    clicks += 1;
    if (count) count.textContent = String(clicks);
  });
}

document.addEventListener("astro:page-load", setupNativeDemo);

astro:page-load 會在直接載入與後續每次 client
navigation 完成時觸發。初始化函式每次重新查詢目前 document 的元素,data-initialized 則避免同一個節點被重複綁定。

Astro 也提供 data-astro-rerun,可強迫 inline
script 每次 navigation 後重新執行。但它不該取代 lifecycle 判斷。需要操作新頁 DOM 時,用 astro:page-load
表達時機,通常更容易看懂,也更容易加入 guard 或 teardown。

五個 lifecycle 不必硬背

ClientRouter 的 navigation lifecycle 順序如下:

astro:before-preparation
  → astro:after-preparation
  → astro:before-swap
  → astro:after-swap
  → astro:page-load

用 navigation 的三個階段理解會比較直接:

階段 Event 適合處理的事
準備下一頁 before-preparationafter-preparation loading 狀態、包裝下一頁 loader
交換 DOM before-swapafter-swap 修改新 document、調整 swap、處理 scroll
新頁完成 page-load 查詢新 DOM、重新掛互動

before-preparation 發生在下一頁 request 送出前;after-preparation 代表下一頁已載入。before-swap
時新 document 已解析,但舊內容還沒換掉。after-swap 觸發時,history 與 scroll position 已更新。最後的 page-load
表示新頁已可見,blocking styles 與 scripts 也已完成。

實際記錄 lifecycle

/demos/view-transitions 使用一段原生 script 監聽五個 lifecycle 事件,將最近 20 筆記錄放進
sessionStorage。從 demo 前往首頁,再按瀏覽器返回,畫面留下兩輪完整順序:

View Transitions lifecycle demo 依序記錄前往首頁與返回時的十個事件

前往首頁的實測記錄是:

astro:before-preparation  /demos/view-transitions/ → /
astro:after-preparation   /demos/view-transitions/
astro:before-swap         /demos/view-transitions/ → /
astro:after-swap          /
astro:page-load           /

瀏覽器返回時也依同一順序再走一次。普通 link navigation 與 history
traversal 都會進入相同 lifecycle,因此初始化程式不能只處理「使用者點了連結」這一種入口。

怎麼確認不是看起來像 SPA 而已?

只看動畫很容易誤判,所以還要檢查 document 與 browser state:

  1. 在 demo 先寫入 window.day25DocumentMarker = "persists"
  2. 點普通 <a> 前往首頁。
  3. 檢查 marker、Navigation Timing、URL、title、H1 與 Header active state。

結果如下:

{
  "marker": "persists",
  "navigationEntries": 1,
  "active": "首頁",
  "announcement": "用 Astro 打造 Content-first 前端網站"
}

window marker 沒消失,document navigation
entry 仍只有 1 筆,表示沒有完整 reload;URL、title、H1 與 Header 則都換成新頁內容。檢查時也發現 Header 原有的路徑比對問題:preview
URL 是 /blog/,原本只比對 /blog,所以「搜尋文章」沒有 aria-current。把尾斜線正規化後,direct load 與 client
navigation 都能正確標示目前頁面。

production build 會多出一個 16,154 bytes、尚未 gzip 的 ClientRouter JavaScript chunk。這份 chunk 不大,仍是額外的 client
JavaScript;如果網站只需要標準 MPA navigation,就沒有必要為了「看起來比較現代」加入 ClientRouter。

reduced motion 真的會停動畫嗎?

官方文件指出,ClientRouter 內建 prefers-reduced-motion 支援。實測時固定 browser、route、viewport 與 production
build,只切換 reduced-motion。

astro:before-swap 取得 event.viewTransition,等待 viewTransition.ready 後讀取 animations:

條件 執行中的 View Transition animations
一般 motion 8
prefers-reduced-motion: reduce 0

一般模式可看到 root 與 page-content 的 transition pseudo-elements,duration 分別為 250ms 與 180ms。切換 reduced
motion 後沒有任何 View Transition
animation;URL、內容交換與 lifecycle 仍正常完成。在 ClientRouter 中,少動偏好會停用轉場動畫,不會關閉 navigation。

ClientRouter 的保護範圍只包含它所管理的動畫。若另外加入 CSS
animation、canvas 或 JavaScript 動畫,仍要個別處理 reduced-motion。

不支援 View Transitions 的瀏覽器怎麼辦?

ClientRouterfallback 有三種值:

設定 不支援原生 View Transitions API 時
animate 預設;Astro 模擬動畫,繼續 client navigation
swap 不動畫,直接交換 DOM,繼續 client navigation
none 退回完整頁面 navigation

這裡未設定 fallback,因此採用官方預設的 animate。實測涵蓋支援 View
Transitions 的 Chrome;表格中的三種 fallback 行為來自 Astro 官方文件,其他瀏覽器路徑未實測。

client-side navigation 還有哪些可及性責任?

傳統整頁載入會自然讓輔助科技知道頁面已改變;client-side navigation 必須補回這個訊號。ClientRouter 內建一個
aria-live="assertive" route announcer,公告文字依序取:

  1. 新頁 <title>
  2. 第一個 <h1>
  3. pathname。

實測從 Day 24 回到首頁,live region 的文字等於首頁 <title><title> 同時服務 SEO 與 client navigation 的可及性。這和
Day 20 的 Endpoint/RSS不同:JSON、RSS、下載或外部目的地不屬於一般 HTML page
navigation,不能一概交給 client router。

什麼情況不該開 ClientRouter?

開啟前先確認需求真的包含以下至少一項:

  • 頁面之間需要視覺連續感。
  • 需要跨頁保留某個 DOM、island 或媒體狀態。
  • 需要控制 navigation preparation、swap 或完成時機。

如果只想讓內容頁「快一點」,先量測再決定。ClientRouter 會增加 client
JavaScript,也要求逐一檢查既有 scripts;原生 browser navigation 已經夠用的站,保持 MPA 反而更省心。

ClientRouter 的範圍由 layout 決定。這個專案只有使用 BaseLayout
的正式頁加入 ClientRouter,bench 與獨立 MDX 頁不在同一個 shell;在一個 layout 加入 ClientRouter,不會自動覆蓋所有 route。

今日驗收

production build 與瀏覽器回歸結果如下:

  • Astro 7.1.1 build 成功,/demos/view-transitions 完成預渲染。
  • 前往首頁與瀏覽器返回各自依序觸發五個 lifecycle 事件。
  • Day 7 原生計數器在 client navigation 進入、離頁再返回後仍可互動。
  • /blog 的 SearchFilter 與文章 ReactionButton 都能重新 hydrate。
  • route announcer 文字取自新頁 title。
  • reduced motion 將 8 個 animations 降為 0,navigation 仍正常。
  • 1440×1000 與 390×844 都沒有水平溢出。
  • browser page errors 與 console errors 都是 0。

ClientRouter 能讓 MPA 擁有 SPA 般的換頁體驗。導入時還要驗證 script 重跑時機、狀態保留、fallback、少動偏好與 route
announcement;這些項目都經過 production build 與瀏覽器回歸驗證。

下一篇進入 Day 26 i18n。當換頁變得連續、語系也進入 URL 時,route、內容與 language
picker 的分工需要明確,避免兩套路徑逐漸分歧。

官方查證:View Transitions guideView Transitions Router APIAstro 5 upgrade:ViewTransitions 改名為 ClientRouter


上一篇
想幫內容站加登入和收藏,2026 的 Astro 該把驗證交給誰?
下一篇
要支援多語系時,路由與內容怎麼組織才不會失控?
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言